原生拖拽重排

ForEach 是 Scripting 中用于渲染可变数量子视图的组件,用于构建动态列表、可编辑列表,以及支持系统级的删除与移动行为。其设计参考 SwiftUI 的 ForEach,并与 Scripting 的 Observable 状态管理系统深度集成。

组件支持两种模式:

  1. 旧版模式(已不推荐使用):count + itemBuilder
  2. 推荐的现代模式:data: Observable<T[]> + builder

1. 类型定义

ForEachDeprecatedProps(已不推荐)

type ForEachDeprecatedProps = {
  count: number;
  itemBuilder: (index: number) => VirtualNode;
  onDelete?: (indices: number[]) => void;
  onMove?: (indices: number[], newOffset: number) => void;
};

参数说明

count: number

要渲染的元素数量,itemBuilder 将从 0 到 count - 1 依次构建每个子视图。

itemBuilder(index)

基于索引构建一个 VirtualNode。

onDelete(indices)

注册删除行为。 当 ForEach 放置在 List 中时,如果提供 onDelete,将启用系统级的滑动删除交互。 回调触发时,List 中对应的行已被移除,你必须在回调中同步删除数据源中的对应项目。

onMove(indices, newOffset)

注册移动行为,用于支持编辑状态下的拖动排序。 如希望禁用移动能力,可传入 null。


2. ForEachProps(推荐使用)

type ForEachProps<T extends { id: string }> =
  | ForEachDeprecatedProps
  | {
      data: Observable<T[]>;
      builder: (item: T, index: number) => VirtualNode;
      editActions?: "delete" | "move" | "all" | null;
    };

参数说明

data: Observable<T[]>

一个可观察数组,数组元素必须包含唯一的 id: string 字段。

使用 Observable 的好处:

  • 当数组变动(增删改)时,会自动触发 SwiftUI 刷新
  • 可以保留动画
  • 更接近 SwiftUI 中 ForEach($items) 的使用体验
  • 支持与 List、NavigationStack 等组件无缝联动

builder(item, index)

用于基于当前数组的每个元素构建对应的 VirtualNode。

注意:必须为返回的子节点提供唯一的 key(通常使用 item.id)。

editActions: "delete" | "move" | "all" | null

控制 ForEach 的可编辑能力:

值含义
"delete"仅启用删除
"move"仅启用移动(拖动排序)
"all"同时启用删除与移动
null不提供任何编辑能力(默认)

当 ForEach 位于 List 内部时,编辑能力会自动映射到系统提供的交互方式。


3. ForEachComponent 接口

interface ForEachComponent {
  <T extends { id: string }>(props: ForEachProps<T>): VirtualNode;
}

ForEach 是一个泛型组件,接受带有 id 的任意数据类型。


4. 系统级删除交互示例

当 ForEach 放在 List 内部,并使用 data + builder 模式时,系统会自动启用 swipe-to-delete,只需正确提供 id 和编辑能力。

示例代码

function View() {
  const fruits = useObservable(() =>
    ["Apple", "Bananer", "Papaya", "Mango"].map((name, index) => ({
      id: index.toString(),
      name,
    }))
  );

  return (
    <NavigationStack>
      <List
        navigationTitle="Fruits"
        toolbar={{
          topBarTrailing: <EditButton />,
        }}>
        <ForEach data={fruits} builder={(item, index) => <Text key={item.id}>{item.name}</Text>} />
      </List>
    </NavigationStack>
  );
}

5. 使用建议与最佳实践

1. 推荐使用 data: Observable<T[]> 方案

新版 API 更接近 SwiftUI 行为,拥有更好的性能与类型推断支持,且未来将接入更多 SwiftUI-style 的能力。

2. 每个元素必须拥有 id: string

这是确保 Diff 和动画正确工作的基础。

3. 必须为 builder 返回的节点提供 key={item.id}

否则可能导致:

  • 动画不生效
  • 列表渲染混乱
  • 删除或移动行为出错

4. 若需要与编辑按钮联动,必须放置于 List 中

并设置 toolbar:

toolbar={{
  topBarTrailing: <EditButton />
}}

原生拖拽重排(iOS 27.0+)

使用 data 的 ForEach 支持 reorderable?: boolean | { collectionID: string },需放在设置 reorderContainer 的父容器中。默认关闭;旧系统忽略原生重排。现有 ReorderableForEach 组件保留原有行为。

reorderContainer 参数为 { isEnabled?: boolean, acrossCollections?: boolean, move: (difference: ReorderDifference) => void },默认启用、单集合。difference 包含 sources: string[] 和目标 { position: "before", itemID: string } 或 { position: "end" }。多集合时设置 acrossCollections: true,每个 ForEach 提供 collectionID,目标会额外包含 collectionID。DropSession.reorderDestination 使用同样的目标格式。

在 move 中同步更新 Observable 数据:先移除 sources 中的条目,再按 sources 的顺序插到目标 ID 前或数组末尾。多集合需同时更新来源和目标集合。容器内 ID 应保持唯一。这与 editActions 独立,不会自动改动数据数组。

<VStack reorderContainer={{ move: ({ sources, destination }) => {
  const moving = sources.map(id => items.value.find(item => item.id === id)!)
  const remaining = items.value.filter(item => !sources.includes(item.id))
  const index = destination.position === "end" ? remaining.length
    : remaining.findIndex(item => item.id === destination.itemID)
  remaining.splice(index < 0 ? remaining.length : index, 0, ...moving)
  items.value = remaining
}}}>
  <ForEach data={items} reorderable builder={item => <Text key={item.id}>{item.name}</Text>} />
</VStack>

重排回调类型

type ReorderDestination = (
  | { position: "before", itemID: string }
  | { position: "end" }
) & { collectionID?: string }

type ReorderDifference = {
  sources: string[]
  destination: ReorderDestination
}